RSMS results storage — implementation plan (Parquet / DuckDB era)
Archived. Historical Parquet + DuckDB validation phased plan — do not implement. Canonical today:
rsms-worker-function/rsms_results_storage/orchestrated bywrite_scenario_results_to_azure; see rsms-results-storage-design.
Status (historical archive): The Parquet triple (cxplt_time, cxplt_distance, mass_balance.parquet) and DuckDB validation flows in §4 Phases B–D below are retired for the product path. Normative storage design: rsms-results-storage-design. Migration / sequencing: rsms-results-table-storage-rework-plan (Phase 1 writer replaces dual Parquet with Azure Table float32 rows + minified JSON blobs).
This document remains useful for package layout (rsms-worker-function/rsms_results_storage/), parser reuse from file_parsers.py, and historical phase structure — adjust exit criteria to match table writers instead of write_results_parquet_set. The RSMS worker (rsms-worker-function, queue-driven or CLI) remains the integration point for both engine execution and results persistence in production (rsms-results-table-storage-rework-plan Phase 1b).
This plan originally turned the Parquet-era storage design into code. The canonical package lives under rsms-worker-function/rsms_results_storage/ (owned by the worker repo — no separate top-level library project; reuse is only within the worker unless needs change). It assumed Python 3.10+, PyArrow for Parquet writes, and optional DuckDB for validation queries.
Companion doc: rsms-results-storage-design
1. Recommended work surface: modules + thin entrypoints (not “notebook-first”)
| Option | Pros | Cons |
|---|---|---|
| Notebook only | Fast exploration | Hard to import in the production worker layout; copy-paste drift |
Standalone .py only | Runnable from CLI | Less convenient for ad-hoc plots |
| Shared package + script + notebook | One implementation; notebook imports the same functions | Slightly more structure up front |
Decision: Implement core logic in importable Python modules under a single package path (see §3). Add:
- A thin CLI (e.g.
python -m ... write-parquet --results-dir ...) for batch runs, or - A minimal notebook that only imports the package, calls public functions, and plots — no business logic in notebook cells.
That matches “you do the heavy lifting in code” while letting you use a notebook for visualization. The worker imports from rsms_results_storage (e.g. from rsms_results_storage.pipeline import ... — align PYTHONPATH / package layout under rsms-worker-function).
2. Design principles (worker-ready)
- Pure functions where possible:
(parsed_tables | paths) → pyarrow.Tableor→ None(upload side effects isolated). - No global mutable config: pass
RunConfig-like or explicitResultsStorageConfigdataclass (paths,run_base,riverbasin_id,scenario_id, tolerances for future CTPLT). - Stable public API: a small set of entrypoints:
load_cxplt_from_plt(path, timesteps) -> CXPLTFrame(name TBD)cxplt_to_sparse_tables(...) -> tuple[pa.Table, pa.Table](time-first, distance-first)load_mass_balance_from_mas(...) -> pa.Tablewrite_results_parquet_set(out_dir, tables, ...)(local dir first; upload Parquet separately)
- Reuse existing parsers: refactor or wrap
rsms-worker-function/file_parsers.py(parse_tim,parse_plt,parse_mas) so they can return columnar structures or feed the new layer — avoid duplicating.PLTformat logic. - I/O boundaries: Inputs are always local paths (engine output directory on the worker host, or a dev folder). Azure Blob is outputs only: upload Parquet to
rsms-results, and optionally upload raw engine files toengine-files— use aBlobSink-style helper (upload_file(local, key)/upload_bytes). No blob download for aggregation inputs in the worker; the engine has already written the files locally. - Schema: column names and dtypes match
ssot/rsms-ssot.jsononce it exists (§5); until then, constants in one module (SCHEMA_CXPLT = ...).
3. Repository layout (decided)
Canonical: the implementation lives under rsms-worker-function/rsms_results_storage/ (subpackage; e.g. pipeline.py, azure_table_plume.py, …). The worker adds one import path and there is no separate top-level rsms-results-storage/ project in this monorepo — we do not expect to consume this code from other repositories.
rsms-suite/
rsms-worker-function/
rsms_results_storage/ # canonical package (Table + blob writers today)
__init__.py
config.py
pipeline.py
...
file_parsers.py # existing; shared PLT/MAS helpers as needed
...
Dev / notebooks: rsms-notebooks/storage/ may keep a scratch copy or symlink for CLI/notebook runs, or point PYTHONPATH at the worker folder. Prefer one source of truth under rsms-worker-function/rsms_results_storage.
Notebook (rsms-notebooks/storage/results_parquet_dev.ipynb): imports only the package; no chart business logic in cells.
4. Implementation phases
Phase A — SSOT stub and types (0.5–1 day)
- Add
ssot/rsms-ssot.json(minimal: entity names + column lists + dtypes) orconfig.pyenums mirroring the design doc until SSOT is formalized. - Define
CXPLTRow/MassBalanceRow(dataclass orTypedDict) andhours,miles,concentrationnaming consistent with DuckDB examples in the design doc.
Exit: Single import path for column name strings; no magic strings scattered.
Phase B — CXPLT: parse → long table → sparse → dual Parquet (core)
- Input:
.TIM+.PLT(same semantics asfile_parsers.parse_tim/parse_plt). - Flatten
mile_conc_by_timeto a long list of(hours, miles, concentration)(floats). - Sparse rule: drop rows with
concentration == 0(orabs(conc) < epsilonif floats demand — document epsilon in config). - Sort copy A:
(hours, miles)→cxplt_time.parquet. - Sort copy B:
(miles, hours)→cxplt_distance.parquet. - PyArrow:
pa.Table.from_pandasor column builders; Snappy or Zstd compression; row group size tuned experimentally.
Exit: Two files written locally; row counts << dense grid if PLT is sparse.
Phase C — Mass balance Parquet
- Reuse
parse_masoutput or parse .MAS in the same package. - Build
mass_balance.parquetsorted byhours; decide dense vs drop-zero for PMASS/DZMASS (open item in design doc §12).
Exit: Third file; DuckDB SELECT COUNT(*), MIN(hours), MAX(hours) sanity check.
Phase D — Validation (DuckDB)
validate.py: run the design doc §10 queries against the three files (local paths).- Optional:
read_parquet+WHERE hours = ?oncxplt_timeand compare row counts to a reference hour from raw PLT.
Exit: Scripted check you can run in CI later.
Phase E — Blob upload (outputs only)
- Upload Parquet to
rsms-resultsand (if required by product) raw engine artifacts toengine-files, each under{riverbasin_id}/{scenario_id}/. - Inputs for parsing remain the local working directory after the engine run — not re-fetched from
engine-filesfor this pipeline. - Idempotency: overwrite same blob names on retry (per design).
- Use
AZURE_STORAGE_CONNECTION_STRING(same pattern as existingresults_blob_upload.py).
Exit: After a successful local run folder → Parquet blobs in rsms-results (and optional engine-files copies).
Phase F — Worker integration
- Import from
rsms_results_storageunderrsms-worker-functionin the engine/results pipeline after parse (seerun_rsms_handlers/engine_workflow). - Feature flag (env var):
WRITE_RESULTS_PARQUET=1to avoid breaking existing JSON-only responses until tested. - Logging: blob URLs + row counts in pipeline result JSON.
Exit: A successful worker run produces Parquet in storage on success (historical Phase F target; product path today is Table + JSON, not Parquet).
Phase G — Tests
- Unit: sparse filter (input with zeros → output without).
- Unit: dual sort orders produce identical multiset of non-zero rows.
- Golden file (optional): small
.PLT/.MASfixtures underrsms-worker-function/tests/fixtures/(or next torsms_results_storage/).
5. ON HOLD — CTPLT (reference for later)
Not implemented until product defines the interpolation algorithm. Keep this section as a checklist when unblocked. Persistent TODO (Sudhir code adaptation, one-time generation + storage like mass balance, edge logic vs concentration_tolerance): rsms-results-backlog §1.
| Item | Reference |
|---|---|
| Legacy algorithm | rsms-legacy/BusinessLogic/CTPLTGenerator.cs — requires CXPLT full set ordered by time, distance; mile step rmStepSize; MinLimit cutoff |
| Outputs | ctplt_time.parquet, ctplt_distance.parquet (same dual-layout idea as CXPLT) |
| Downstream | Legacy edge/peak charts use CTPLTResults + Runs.SimParamsConcentrationtolerance — see design §2.3 |
| Code shape | ctplt.py: interpolate_cxplt_to_ctplt(cxplt_long_df, config) -> pa.Table then reuse parquet_write.dual_sort |
| SSOT | Add ctplt_time, ctplt_distance entities |
Dependency: CXPLT Parquet (or in-memory CXPLT long table) must be complete enough for interpolation — if CXPLT is sparse, interpolation must define behavior at missing (hour, mile) (treat as 0).
6. Risks and mitigations
| Risk | Mitigation |
|---|---|
| Sparse CXPLT breaks assumptions in a naive port of CTPLTGenerator | Document fill-zero before interpolation; add Phase G tests when CTPLT un-holds |
Duplicate parser logic vs file_parsers.py | Extract shared plt_parser module used by worker and storage package |
| Large Parquet files | Row groups + compression; monitor Elk-scale runs (design §12) |
7. Open decisions (pre-coding)
- Float epsilon for “zero” concentration when dropping rows.
- Mass balance: keep all hours or omit zero rows (design §12 item 4).
8. Document index
| Doc | Role |
|---|---|
| rsms-results-storage-design | What to store (Table plume grid + JSON blobs, 0.2 mi step) |
| rsms-results-table-storage-rework-plan | Migration phases (writer + API) |
| This file | How to implement package layout + historical Parquet phases (legacy) |
Implementation plan — package home: rsms-worker-function/rsms_results_storage/.